﻿using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using housingsafety.server.Caching.Core;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using StackExchange.Redis;

namespace housingsafety.server.Caching.Redis
{
    public class RedisCache : IDistributedCache, IDisposable
    {
        private const string m_ExpirationKey = "exp";
        private const string m_DataKey = "data";
        private const long m_NotPresent = -1;

        private RedisService m_RedisCacheService;
        private readonly string m_InstanceName;

        public RedisCache(RedisService redisCacheService, IOptions<RedisCacheOptions> options)
        {
            Requires.NotNull(redisCacheService, nameof(redisCacheService));
            Requires.NotNull(options, nameof(options));
            m_RedisCacheService = redisCacheService;
            m_InstanceName = options.Value.InstanceName ?? string.Empty;
        }

        public void Dispose() { }

        public byte[] Get(string key)
        {
            return GetAndRefresh(CreateCacheKeyString(key), getData: true);
        }

        public Task<byte[]> GetAsync(string key, CancellationToken token = new CancellationToken())
        {
            return GetAndRefreshAsync(CreateCacheKeyString(key), getData: true, token);
        }

        public void Refresh(string key)
        {
            GetAndRefresh(CreateCacheKeyString(key), getData: false);
        }

        public Task RefreshAsync(string key, CancellationToken token = new CancellationToken())
        {
            return GetAndRefreshAsync(CreateCacheKeyString(key), getData: false, token);
        }

        public void Remove(string key)
        {
            m_RedisCacheService.Remove(CreateCacheKeyString(key));
        }

        public Task RemoveAsync(string key, CancellationToken token = new CancellationToken())
        {
            return m_RedisCacheService.RemoveAsync(CreateCacheKeyString(key), token);
        }

        public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
        {
            key = CreateCacheKeyString(key);
            var creationTime = DateTimeOffset.UtcNow;
            var absoluteExpiration = GetAbsoluteExpiration(creationTime, options);
            var expirationValue = CreateExpirationValue(absoluteExpiration, options.SlidingExpiration);
            m_RedisCacheService.Set(key, new HashEntry[] {
                new HashEntry(m_ExpirationKey, expirationValue),
                new HashEntry(m_DataKey, value)
            });
            var expireation = GetExpirationForSet(creationTime, absoluteExpiration, options);
            if (expireation.HasValue)
            {
                m_RedisCacheService.KeyExpire(key, expireation.Value);
            }
        }

        public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options,
            CancellationToken token = new CancellationToken())
        {
            key = CreateCacheKeyString(key);
            var creationTime = DateTimeOffset.UtcNow;
            var absoluteExpiration = GetAbsoluteExpiration(creationTime, options);
            var expirationValue = CreateExpirationValue(absoluteExpiration, options.SlidingExpiration);
            await m_RedisCacheService.SetAsync(key, new HashEntry[] {
                new HashEntry(m_ExpirationKey, expirationValue),
                new HashEntry(m_DataKey, value)
            });
            var expireation = GetExpirationForSet(creationTime, absoluteExpiration, options);
            if (expireation.HasValue)
            {
                await m_RedisCacheService.KeyExpireAsync(key, expireation.Value);
            }
        }

        private string CreateCacheKeyString(string key)
        {
            return m_InstanceName + key;
        }

        private string CreateExpirationValue(DateTimeOffset? absoluteExpiration, TimeSpan? slidingExpiration)
        {
            return string.Format("{0},{1}", absoluteExpiration?.Ticks ?? m_NotPresent, slidingExpiration?.Ticks ?? m_NotPresent);
        }

        private byte[] GetAndRefresh(string key, bool getData)
        {
            HashEntry[] results;
            if (getData)
            {
                results = m_RedisCacheService.HashGet(key);
            }
            else
            {
                results = m_RedisCacheService.HashGet(key, m_ExpirationKey);
            }

            if (results.Length > 0)
            {
                var expiration = results.FirstOrDefault(r => r.Name == m_ExpirationKey);
                if (expiration != null)
                {
                    DateTimeOffset? absExpr;
                    TimeSpan? sldExpr;
                    MapHashMembers(expiration.Value, out absExpr, out sldExpr);
                    Refresh(key, absExpr, sldExpr);
                }
            }

            if (getData)
            {
                var data = results.FirstOrDefault(r => r.Name == m_DataKey);
                if (data != null)
                    return data.Value;
            }

            return null;
        }

        private async Task<byte[]> GetAndRefreshAsync(string key, bool getData, CancellationToken token = new CancellationToken())
        {
            Requires.NotNullOrEmpty(key, nameof(key));
            HashEntry[] results;
            if (getData)
            {
                results = await m_RedisCacheService.HashGetAsync(key);
            }
            else
            {
                results = await m_RedisCacheService.HashGetAsync(key, m_ExpirationKey);
            }

            if (results.Length > 0)
            {
                var expiration = results.FirstOrDefault(r => r.Name == m_ExpirationKey);
                if (expiration != null)
                {
                    DateTimeOffset? absExpr;
                    TimeSpan? sldExpr;
                    MapHashMembers(expiration.Value, out absExpr, out sldExpr);
                    await RefreshAsync(key, absExpr, sldExpr);
                }
            }

            if (getData)
            {
                var data = results.FirstOrDefault(r => r.Name == m_DataKey);
                if (data != null)
                    return data.Value;
            }

            return null;
        }

        private void MapHashMembers(RedisValue results, out DateTimeOffset? absoluteExpiration, out TimeSpan? slidingExpiration)
        {
            absoluteExpiration = null;
            slidingExpiration = null;
            var split = ((string)results).Split(',');
            var absoluteExpirationTicks = long.Parse(split[0]);
            if (absoluteExpirationTicks != m_NotPresent)
            {
                absoluteExpiration = new DateTimeOffset(absoluteExpirationTicks, TimeSpan.Zero);
            }
            var slidingExpirationTicks = long.Parse(split[1]);
            if (slidingExpirationTicks != m_NotPresent)
            {
                slidingExpiration = new TimeSpan(slidingExpirationTicks);
            }
        }

        private void Refresh(string key, DateTimeOffset? absExpr, TimeSpan? sldExpr)
        {
            TimeSpan? expr = null;
            if (sldExpr.HasValue)
            {
                expr = GetGetExpirationForRefresh(sldExpr, absExpr);
                m_RedisCacheService.KeyExpire(key, expr);
            }
        }

        private async Task RefreshAsync(string key, DateTimeOffset? absExpr, TimeSpan? sldExpr)
        {
            TimeSpan? expr = null;
            if (sldExpr.HasValue)
            {
                expr = GetGetExpirationForRefresh(sldExpr, absExpr);
                await m_RedisCacheService.KeyExpireAsync(key, expr);
            }
        }

        private DateTimeOffset? GetAbsoluteExpiration(DateTimeOffset creationTime, DistributedCacheEntryOptions options)
        {
            if (options.AbsoluteExpiration.HasValue && options.AbsoluteExpiration <= creationTime)
            {
                throw new ArgumentOutOfRangeException(
                    nameof(DistributedCacheEntryOptions.AbsoluteExpiration),
                    options.AbsoluteExpiration.Value,
                    "The absolute expiration value must be in the future.");
            }
            var absoluteExpiration = options.AbsoluteExpiration;
            if (options.AbsoluteExpirationRelativeToNow.HasValue)
            {
                absoluteExpiration = creationTime + options.AbsoluteExpirationRelativeToNow;
            }

            return absoluteExpiration;
        }

        private TimeSpan? GetExpirationForSet(DateTimeOffset creationTime, DateTimeOffset? absoluteExpiration, DistributedCacheEntryOptions options)
        {
            if (absoluteExpiration.HasValue && options.SlidingExpiration.HasValue)
            {
                var relExpr = absoluteExpiration.Value - creationTime;
                return relExpr <= options.SlidingExpiration.Value ? relExpr : options.SlidingExpiration.Value;
            }
            else if (absoluteExpiration.HasValue)
            {
                return (absoluteExpiration.Value - creationTime);
            }
            else if (options.SlidingExpiration.HasValue)
            {
                return options.SlidingExpiration.Value;
            }
            return null;
        }

        private TimeSpan? GetGetExpirationForRefresh(TimeSpan? sldExpr, DateTimeOffset? absExpr)
        {
            if (absExpr.HasValue)
            {
                var relExpr = absExpr.Value - DateTimeOffset.Now;
                return relExpr <= sldExpr.Value ? relExpr : sldExpr;
            }
            return sldExpr;
        }
    }
}
